--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit de74a738b4641c65cc3e882e425e7479cf304e56
Parents : 02eda43
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-23T02:27:41-05:00
feat(repository-server): add repository server navigation and enhance conversation message entry with map link support
Changes
5 files changed, 610 insertions(+), 163 deletions(-)
Diff
diff --git a/meshchatx/src/frontend/components/CommandPalette.vue b/meshchatx/src/frontend/components/CommandPalette.vue
index a7ca6def..d085eb5c 100644
--- a/meshchatx/src/frontend/components/CommandPalette.vue
+++ b/meshchatx/src/frontend/components/CommandPalette.vue
@@ -257,6 +257,14 @@ export default {
type: "navigation",
route: { name: "documentation" },
},
+ {
+ id: "nav-repository-server",
+ title: "nav_repository_server",
+ description: "nav_repository_server_desc",
+ icon: "package-variant",
+ type: "navigation",
+ route: { name: "repository-server" },
+ },
{
id: "nav-micron-editor",
title: "nav_micron_editor",
diff --git a/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue b/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue
index b4c854fb..a598429c 100644
--- a/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue
@@ -500,6 +500,7 @@
v-if="
chatItem.lxmf_message.content &&
!cv.getParsedItems(chatItem)?.isOnlyPaperMessage &&
+ !cv.getParsedItems(chatItem)?.isOnlyMapLink &&
!cv.shouldHideAutoImageCaption(chatItem)
"
class="leading-relaxed break-words [word-break:break-word] min-w-0 markdown-content"
@@ -635,6 +636,39 @@
Ingest Message
</button>
</div>
+
+ <div
+ v-if="cv.getParsedItems(chatItem).mapLink"
+ class="flex flex-col gap-2 p-3 rounded-xl bg-sky-50 dark:bg-sky-950/30 border border-sky-200 dark:border-sky-800/50"
+ >
+ <div class="flex items-center gap-2 text-sky-800 dark:text-sky-300">
+ <MaterialDesignIcon icon-name="map-marker-radius" class="size-5" />
+ <span class="text-sm font-bold">{{
+ cv.getParsedItems(chatItem).mapLink.kind === "ping"
+ ? $t("messages.map_link_ping_title")
+ : $t("messages.map_link_share_title")
+ }}</span>
+ </div>
+ <div class="text-[10px] font-mono text-sky-900/80 dark:text-sky-200/90 break-all">
+ {{ cv.getParsedItems(chatItem).mapLink.parsed.lat.toFixed(5) }},
+ {{ cv.getParsedItems(chatItem).mapLink.parsed.lon.toFixed(5) }}
+ (z{{ cv.getParsedItems(chatItem).mapLink.parsed.zoom }})
+ </div>
+ <button
+ type="button"
+ class="w-full py-2 bg-sky-600 hover:bg-sky-700 text-white rounded-lg text-xs font-bold transition-colors shadow-sm"
+ @click="cv.openMapShareFromParsed(cv.getParsedItems(chatItem).mapLink.parsed)"
+ >
+ {{ $t("messages.map_link_open") }}
+ </button>
+ <button
+ type="button"
+ class="w-full py-2 bg-white dark:bg-zinc-900 border border-sky-200 dark:border-sky-800 text-sky-800 dark:text-sky-200 rounded-lg text-xs font-bold"
+ @click="cv.copyMapShareUri(cv.getParsedItems(chatItem).mapLink.uri)"
+ >
+ {{ $t("messages.map_link_copy_uri") }}
+ </button>
+ </div>
</div>
<!-- audio field -->
diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 77e80ff5..096cd71d 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -45,143 +45,14 @@
@close="close"
/>
- <!-- Telemetry History Modal -->
- <div
- v-if="isTelemetryHistoryModalOpen"
- class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
- @click.self="isTelemetryHistoryModalOpen = false"
- >
- <div
- class="w-full max-w-lg bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[80vh]"
- >
- <div class="px-6 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
- <div class="flex items-center gap-2">
- <MaterialDesignIcon icon-name="satellite-variant" class="size-6 text-blue-500" />
- <h3 class="text-lg font-bold text-gray-900 dark:text-white">Telemetry History</h3>
- </div>
- <button
- type="button"
- class="text-gray-400 hover:text-gray-500 dark:hover:text-zinc-300 transition-colors"
- @click="isTelemetryHistoryModalOpen = false"
- >
- <MaterialDesignIcon icon-name="close" class="size-6" />
- </button>
- </div>
- <div class="flex-1 overflow-y-auto p-4 space-y-3">
- <div v-if="selectedPeerTelemetryItems.length === 0" class="text-center py-8 text-gray-400">
- No telemetry history found for this peer.
- </div>
- <div
- v-for="item in selectedPeerTelemetryItems"
- :key="item.lxmf_message.hash"
- class="p-3 rounded-xl border border-gray-100 dark:border-zinc-800 bg-gray-50/50 dark:bg-zinc-900/30"
- >
- <div class="flex justify-between items-start mb-2">
- <span
- class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-gray-200 dark:bg-zinc-800 text-gray-600 dark:text-zinc-400"
- >
- {{ item.is_outbound ? "Sent" : "Received" }}
- </span>
- <span class="text-[10px] text-gray-400">{{
- formatTimeAgo(item.lxmf_message.created_at)
- }}</span>
- </div>
-
- <!-- location -->
- <div v-if="item.lxmf_message.fields?.telemetry?.location" class="flex items-center gap-2 mb-2">
- <button
- type="button"
- class="flex items-center gap-2 text-xs font-mono text-blue-600 dark:text-blue-400 hover:underline"
- @click="viewLocationOnMap(item.lxmf_message.fields.telemetry.location)"
- >
- <MaterialDesignIcon icon-name="map-marker" class="size-4" />
- {{ item.lxmf_message.fields.telemetry.location.latitude.toFixed(6) }},
- {{ item.lxmf_message.fields.telemetry.location.longitude.toFixed(6) }}
- </button>
- </div>
-
- <!-- sensors -->
- <div
- v-if="item.lxmf_message.fields?.telemetry"
- class="flex flex-wrap gap-3 text-[10px] text-gray-500"
- >
- <span v-if="item.lxmf_message.fields.telemetry.battery" class="flex items-center gap-1">
- <MaterialDesignIcon icon-name="battery" class="size-3" />
- Battery: {{ item.lxmf_message.fields.telemetry.battery.charge_percent }}%
- </span>
- <span
- v-if="item.lxmf_message.fields.telemetry.physical_link"
- class="flex items-center gap-1"
- >
- <MaterialDesignIcon icon-name="antenna" class="size-3" />
- SNR: {{ item.lxmf_message.fields.telemetry.physical_link.snr }}dB
- </span>
- </div>
-
- <!-- commands -->
- <div
- v-if="item.lxmf_message.fields?.commands?.some((c) => c['0x01'])"
- class="flex items-center gap-2 text-[10px] text-emerald-600 dark:text-emerald-400 mt-1"
- >
- <MaterialDesignIcon icon-name="crosshairs-question" class="size-3" />
- <span>Location Request</span>
- </div>
- </div>
- </div>
- <div
- class="px-6 py-4 border-t border-gray-100 dark:border-zinc-800 bg-gray-50/30 dark:bg-zinc-900/20 flex flex-col gap-4"
- >
- <div v-if="telemetryBatteryHistory.length > 1" class="space-y-2">
- <div class="flex items-center justify-between">
- <span class="text-[10px] font-bold text-gray-400 uppercase tracking-widest"
- >Battery Level (%)</span
- >
- <span class="text-[10px] text-gray-500"
- >{{ telemetryBatteryHistory[0].y }}% →
- {{ telemetryBatteryHistory[telemetryBatteryHistory.length - 1].y }}%</span
- >
- </div>
- <svg class="w-full h-12 overflow-visible" viewBox="0 0 100 100" preserveAspectRatio="none">
- <path
- :d="batterySparklinePath"
- fill="none"
- stroke="#3b82f6"
- stroke-width="2"
- stroke-linecap="round"
- stroke-linejoin="round"
- />
- <circle
- :cx="100"
- :cy="100 - telemetryBatteryHistory[telemetryBatteryHistory.length - 1].y"
- r="3"
- fill="#3b82f6"
- />
- </svg>
- </div>
-
- <div class="flex justify-between items-center w-full">
- <label class="flex items-center gap-2 cursor-pointer group">
- <input
- v-model="showTelemetryInChat"
- type="checkbox"
- class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
- />
- <span
- class="text-xs font-medium text-gray-600 dark:text-zinc-400 group-hover:text-gray-900 dark:group-hover:text-zinc-200"
- >Show telemetry in main chat</span
- >
- </label>
- <button
- type="button"
- class="px-4 py-2 bg-blue-600 text-white text-xs font-bold rounded-lg hover:bg-blue-700 transition-colors shadow-sm"
- @click="isTelemetryHistoryModalOpen = false"
- >
- Done
- </button>
- </div>
- </div>
- </div>
- </div>
+ <TelemetryHistoryModal
+ v-model="isTelemetryHistoryModalOpen"
+ v-model:show-telemetry-in-chat="showTelemetryInChat"
+ :telemetry-items="selectedPeerTelemetryItems"
+ :format-time-ago="formatTimeAgo"
+ :gradient-id-suffix="telemetryModalGradientSuffix"
+ @location-click="viewLocationOnMap"
+ />
<!-- Share Contact Modal -->
<div
@@ -1650,12 +1521,14 @@ import ToastUtils from "../../js/ToastUtils";
import PaperMessageModal from "./PaperMessageModal.vue";
import GlobalState from "../../js/GlobalState";
import MarkdownRenderer from "../../js/MarkdownRenderer";
+import { findMapUriInContent, mapLinkKindFromMessage, parseMeshchatMapUri } from "../../js/mapLinkUtils.js";
import { COLUMBA_REACTION_EMOJIS, mergeLxmfReactionRowsIntoMessages } from "../../js/lxmfReactions";
import { createOutboundQueue } from "../../js/outboundSendQueue";
import emojiPickerEnDataUrl from "emoji-picker-element-data/en/emojibase/data.json?url";
import "emoji-picker-element";
import StickerView from "../stickers/StickerView.vue";
import InViewAnimatedImg from "./InViewAnimatedImg.vue";
+import TelemetryHistoryModal from "./telemetry/TelemetryHistoryModal.vue";
export default {
name: "ConversationViewer",
@@ -1675,6 +1548,7 @@ export default {
ConversationMessageListVirtual,
StickerView,
InViewAnimatedImg,
+ TelemetryHistoryModal,
},
props: {
config: {
@@ -2112,30 +1986,9 @@ export default {
(item) => item.is_outbound && ["failed", "cancelled"].includes(item.lxmf_message?.state)
);
},
- telemetryBatteryHistory() {
- return this.selectedPeerTelemetryItems
- .filter((item) => item.lxmf_message.fields?.telemetry?.battery)
- .map((item) => ({
- x: item.lxmf_message.timestamp,
- y: item.lxmf_message.fields.telemetry.battery.charge_percent,
- }))
- .sort((a, b) => a.x - b.x);
- },
- batterySparklinePath() {
- const history = this.telemetryBatteryHistory;
- if (history.length < 2) return "";
-
- const minX = history[0].x;
- const maxX = history[history.length - 1].x;
- const rangeX = maxX - minX || 1;
-
- return history
- .map((p, i) => {
- const x = ((p.x - minX) / rangeX) * 100;
- const y = 100 - p.y; // SVG y is top-down
- return `${i === 0 ? "M" : "L"} ${x} ${y}`;
- })
- .join(" ");
+ telemetryModalGradientSuffix() {
+ const h = this.selectedPeer?.destination_hash || "default";
+ return h.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 24) || "default";
},
},
watch: {
@@ -2292,6 +2145,9 @@ export default {
if (this.getParsedItems(chatItem)?.isOnlyPaperMessage) {
return false;
}
+ if (this.getParsedItems(chatItem)?.isOnlyMapLink) {
+ return false;
+ }
if (this.shouldHideAutoImageCaption(chatItem)) {
return false;
}
@@ -2652,6 +2508,7 @@ export default {
const items = {
contact: null,
paperMessage: null,
+ mapLink: null,
};
// Parse contact: Contact: ivan <ca314c30b27eacec5f6ca6ac504e94c9> [LXMF: ...] [LXST: ...]
@@ -2694,6 +2551,23 @@ export default {
}
}
+ const mapUri = findMapUriInContent(content);
+ if (mapUri && !items.paperMessage) {
+ const parsed = parseMeshchatMapUri(mapUri);
+ if (parsed) {
+ const kind = mapLinkKindFromMessage(content, parsed);
+ let t = content.trim().replace(mapUri, "").trim();
+ t = t
+ .replace(/^MeshChatX\s+map\s+ping:\s*/i, "")
+ .replace(/^MeshChatX\s+map:\s*/i, "")
+ .trim();
+ items.mapLink = { uri: mapUri, parsed, kind };
+ if (t === "") {
+ items.isOnlyMapLink = true;
+ }
+ }
+ }
+
return items;
},
async addContact(name, hash, lxmf_address = null, lxst_address = null) {
@@ -4486,6 +4360,38 @@ export default {
},
});
},
+ openMapShareFromParsed(parsed) {
+ if (!parsed) {
+ return;
+ }
+ const query = {
+ lat: String(parsed.lat),
+ lon: String(parsed.lon),
+ zoom: String(parsed.zoom),
+ };
+ if (parsed.layers) {
+ query.layers = parsed.layers;
+ }
+ if (parsed.label) {
+ query.label = parsed.label;
+ }
+ this.$router.push({
+ name: "map",
+ query,
+ });
+ },
+ async copyMapShareUri(uri) {
+ if (!uri) {
+ return;
+ }
+ try {
+ await navigator.clipboard.writeText(uri);
+ ToastUtils.success(this.$t("messages.map_link_copied"));
+ } catch (e) {
+ console.error(e);
+ ToastUtils.error(this.$t("messages.failed_copy_address"));
+ }
+ },
isTelemetryOnly(msg) {
return isTelemetryOnlyMessage(msg);
},
diff --git a/meshchatx/src/frontend/components/tools/RepositoryServerPage.vue b/meshchatx/src/frontend/components/tools/RepositoryServerPage.vue
new file mode 100644
index 00000000..4749c7a5
--- /dev/null
+++ b/meshchatx/src/frontend/components/tools/RepositoryServerPage.vue
@@ -0,0 +1,491 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
+ <div class="flex-1 overflow-y-auto w-full pb-[max(1rem,env(safe-area-inset-bottom))]">
+ <div class="p-3 sm:p-4 md:p-6 max-w-6xl mx-auto w-full space-y-4 min-w-0">
+ <div
+ class="flex flex-wrap items-start justify-between gap-3 border-b border-gray-200 dark:border-zinc-800 pb-4"
+ >
+ <div class="flex items-start gap-3 min-w-0">
+ <div
+ class="p-2 bg-sky-100 dark:bg-sky-900/30 text-sky-600 dark:text-sky-300 rounded-lg shrink-0"
+ >
+ <MaterialDesignIcon icon-name="package-variant" class="size-6" />
+ </div>
+ <div class="min-w-0">
+ <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
+ {{ $t("tools.utilities") }}
+ </div>
+ <h1 class="text-lg sm:text-xl font-bold text-gray-900 dark:text-white tracking-tight">
+ {{ $t("tools.repository_server.title") }}
+ </h1>
+ <p class="text-xs sm:text-sm text-gray-600 dark:text-gray-400 mt-1 max-w-2xl">
+ {{ $t("tools.repository_server.subtitle") }}
+ </p>
+ </div>
+ </div>
+ <div class="flex items-center gap-2 shrink-0">
+ <button
+ type="button"
+ class="inline-flex items-center justify-center p-2 rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-gray-600 dark:text-zinc-300 hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors"
+ :title="$t('tools.repository_server.refresh_bundled_tooltip')"
+ :disabled="refreshing"
+ @click="refreshBundled"
+ >
+ <MaterialDesignIcon icon-name="download" class="size-5" />
+ </button>
+ <RouterLink
+ to="/tools"
+ class="inline-flex items-center gap-2 text-sm text-sky-600 dark:text-sky-300 hover:underline"
+ >
+ <MaterialDesignIcon icon-name="arrow-left" class="size-4" />
+ {{ $t("tools.repository_server.back_tools") }}
+ </RouterLink>
+ </div>
+ </div>
+
+ <div
+ v-if="refreshing"
+ class="rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 px-3 py-2.5 space-y-1.5"
+ >
+ <div class="h-1.5 bg-gray-200 dark:bg-zinc-700 rounded-full overflow-hidden">
+ <div
+ class="h-full bg-sky-500 dark:bg-sky-400 rounded-full transition-[width] duration-300 ease-out"
+ :style="{ width: refreshProgressPct + '%' }"
+ />
+ </div>
+ <p v-if="refreshProgressLabel" class="text-xs text-gray-600 dark:text-zinc-400 truncate font-mono">
+ {{ refreshProgressLabel }}
+ </p>
+ </div>
+
+ <div
+ class="rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-4 space-y-3"
+ >
+ <h2 class="text-sm font-semibold text-gray-900 dark:text-white">
+ {{ $t("tools.repository_server.http_heading") }}
+ </h2>
+ <div class="flex flex-wrap gap-3 items-end">
+ <label class="flex flex-col gap-1 text-xs min-w-[10rem]">
+ <span class="text-gray-500 dark:text-zinc-500">{{
+ $t("tools.repository_server.host_label")
+ }}</span>
+ <input
+ v-model="httpHost"
+ type="text"
+ autocomplete="off"
+ class="rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1.5 text-sm text-gray-900 dark:text-white"
+ :disabled="httpBusy || loading || httpRunning"
+ />
+ </label>
+ <label class="flex flex-col gap-1 text-xs w-24">
+ <span class="text-gray-500 dark:text-zinc-500">{{
+ $t("tools.repository_server.port_label")
+ }}</span>
+ <input
+ v-model="httpPort"
+ type="text"
+ inputmode="numeric"
+ autocomplete="off"
+ class="rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1.5 text-sm text-gray-900 dark:text-white"
+ :disabled="httpBusy || loading || httpRunning"
+ />
+ </label>
+ <div class="flex flex-wrap gap-2">
+ <button
+ type="button"
+ class="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700 disabled:opacity-50 disabled:pointer-events-none"
+ :disabled="httpBusy || loading || httpRunning"
+ @click="startHttp"
+ >
+ <MaterialDesignIcon icon-name="play" class="size-4" />
+ {{ $t("tools.repository_server.start_http") }}
+ </button>
+ <button
+ type="button"
+ class="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 text-gray-800 dark:text-zinc-200 text-sm font-medium hover:bg-gray-50 dark:hover:bg-zinc-800 disabled:opacity-50 disabled:pointer-events-none"
+ :disabled="httpBusy || loading || !httpRunning"
+ @click="stopHttp"
+ >
+ <MaterialDesignIcon icon-name="stop" class="size-4" />
+ {{ $t("tools.repository_server.stop_http") }}
+ </button>
+ <button
+ type="button"
+ class="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 text-gray-800 dark:text-zinc-200 text-sm font-medium hover:bg-gray-50 dark:hover:bg-zinc-800 disabled:opacity-50 disabled:pointer-events-none"
+ :disabled="httpBusy || loading"
+ @click="restartHttp"
+ >
+ <MaterialDesignIcon icon-name="restart" class="size-4" />
+ {{ $t("tools.repository_server.restart_http") }}
+ </button>
+ </div>
+ </div>
+ <div v-if="httpRunning && status?.http?.url" class="text-xs space-y-1">
+ <div class="text-gray-600 dark:text-zinc-400">
+ {{ $t("tools.repository_server.http_listen_label") }}
+ <span class="font-mono text-gray-900 dark:text-zinc-200">{{ status.http.url }}</span>
+ </div>
+ <a
+ v-if="browserRepoUrl"
+ :href="browserRepoUrl"
+ target="_blank"
+ rel="noopener noreferrer"
+ class="inline-flex items-center gap-1 text-sky-600 dark:text-sky-400 font-medium hover:underline"
+ >
+ <MaterialDesignIcon icon-name="open-in-new" class="size-4" />
+ {{ $t("tools.repository_server.open_http") }}
+ </a>
+ </div>
+ </div>
+
+ <div
+ class="rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-4 space-y-3"
+ >
+ <h2 class="text-sm font-semibold text-gray-900 dark:text-white">
+ {{ $t("tools.repository_server.upload_heading") }}
+ </h2>
+ <label
+ class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-sky-600 text-white text-sm font-medium cursor-pointer hover:bg-sky-700 transition-colors"
+ >
+ <MaterialDesignIcon icon-name="upload" class="size-4" />
+ {{ $t("tools.repository_server.choose_file") }}
+ <input type="file" class="hidden" @change="onUpload" />
+ </label>
+ <p v-if="lastUploadError" class="text-xs text-red-600 dark:text-red-400">{{ lastUploadError }}</p>
+ </div>
+
+ <div
+ v-if="status?.last_refresh_failed && Object.keys(status.last_refresh_failed).length"
+ class="rounded-xl border border-amber-200 dark:border-amber-900/40 bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-200"
+ >
+ <div class="font-semibold mb-1">{{ $t("tools.repository_server.refresh_partial") }}</div>
+ <ul class="list-disc pl-4 space-y-1">
+ <li v-for="(msg, pkg) in status.last_refresh_failed" :key="pkg">
+ <span class="font-mono">{{ pkg }}</span
+ >: {{ msg }}
+ </li>
+ </ul>
+ </div>
+
+ <div
+ class="rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 overflow-hidden"
+ >
+ <div
+ class="px-4 py-3 border-b border-gray-100 dark:border-zinc-800 flex justify-between items-center"
+ >
+ <h2 class="text-sm font-semibold text-gray-900 dark:text-white">
+ {{ $t("tools.repository_server.files_heading") }}
+ </h2>
+ <span class="text-xs text-gray-500">{{ entries.length }}</span>
+ </div>
+ <div v-if="loading" class="p-6 text-center text-sm text-gray-500">
+ {{ $t("common.loading") }}
+ </div>
+ <div v-else-if="entries.length === 0" class="p-6 text-center text-sm text-gray-500">
+ {{ $t("tools.repository_server.empty") }}
+ </div>
+ <table v-else class="w-full text-left text-xs">
+ <thead class="bg-gray-50 dark:bg-zinc-900/50 text-gray-500 uppercase tracking-wide">
+ <tr>
+ <th class="px-4 py-2">{{ $t("tools.repository_server.col_name") }}</th>
+ <th class="px-4 py-2">{{ $t("tools.repository_server.col_source") }}</th>
+ <th class="px-4 py-2 text-right">{{ $t("tools.repository_server.col_size") }}</th>
+ <th class="px-4 py-2 w-24"></th>
+ </tr>
+ </thead>
+ <tbody class="divide-y divide-gray-100 dark:divide-zinc-800 text-gray-800 dark:text-zinc-200">
+ <tr v-for="row in entries" :key="row.name + row.source">
+ <td class="px-4 py-2 font-mono break-all">{{ row.name }}</td>
+ <td class="px-4 py-2">{{ row.source }}</td>
+ <td class="px-4 py-2 text-right tabular-nums">{{ formatBytes(row.bytes) }}</td>
+ <td class="px-4 py-2 text-right">
+ <button
+ v-if="row.source === 'upload'"
+ type="button"
+ class="text-red-600 dark:text-red-400 hover:underline text-xs font-medium"
+ @click="deleteUpload(row.name)"
+ >
+ {{ $t("tools.repository_server.delete") }}
+ </button>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+import ToastUtils from "../../js/ToastUtils";
+
+export default {
+ name: "RepositoryServerPage",
+ components: { MaterialDesignIcon },
+ data() {
+ return {
+ loading: true,
+ refreshing: false,
+ httpBusy: false,
+ httpHost: "127.0.0.1",
+ httpPort: "8787",
+ status: null,
+ entries: [],
+ lastUploadError: null,
+ refreshPollId: null,
+ };
+ },
+ computed: {
+ refreshProgressPct() {
+ const p = this.status?.refresh_progress;
+ if (!p || !p.total) {
+ return 0;
+ }
+ return Math.min(100, Math.round((100 * p.completed) / p.total));
+ },
+ refreshProgressLabel() {
+ const p = this.status?.refresh_progress;
+ if (!p?.current) {
+ return "";
+ }
+ return this.$t("tools.repository_server.refresh_fetching", { pkg: p.current });
+ },
+ httpRunning() {
+ return Boolean(this.status?.http?.running);
+ },
+ browserRepoUrl() {
+ const raw = this.status?.http?.url;
+ if (!raw) {
+ return null;
+ }
+ try {
+ const parsed = new URL(raw);
+ if (parsed.hostname === "0.0.0.0" && typeof window !== "undefined" && window.location?.hostname) {
+ parsed.hostname = window.location.hostname;
+ }
+ return parsed.toString();
+ } catch {
+ return raw;
+ }
+ },
+ },
+ async mounted() {
+ await this.loadAll();
+ },
+ beforeUnmount() {
+ this.stopRefreshPoll();
+ },
+ methods: {
+ stopRefreshPoll() {
+ if (this.refreshPollId != null) {
+ clearInterval(this.refreshPollId);
+ this.refreshPollId = null;
+ }
+ },
+ syncHttpFormFromStatus() {
+ const h = this.status?.http;
+ if (!h) {
+ return;
+ }
+ if (h.running) {
+ if (h.host) {
+ this.httpHost = h.host;
+ }
+ if (h.port != null) {
+ this.httpPort = String(h.port);
+ }
+ } else if (h.last_host) {
+ this.httpHost = h.last_host;
+ this.httpPort = h.last_port != null ? String(h.last_port) : "8787";
+ }
+ },
+ formatBytes(n) {
+ if (n < 1024) return `${n} B`;
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
+ },
+ httpErrorToast(errKey, detail) {
+ const map = {
+ invalid_host: this.$t("tools.repository_server.http_err_invalid_host"),
+ invalid_port: this.$t("tools.repository_server.http_err_invalid_port"),
+ already_running: this.$t("tools.repository_server.http_err_already_running"),
+ bind_failed: this.$t("tools.repository_server.http_err_bind_failed"),
+ };
+ let msg = map[errKey] || this.$t("tools.repository_server.http_err_generic");
+ if (errKey === "bind_failed" && detail) {
+ msg = `${msg}: ${detail}`;
+ } else if (!map[errKey] && detail) {
+ msg = `${msg}: ${detail}`;
+ }
+ ToastUtils.error(msg);
+ },
+ async loadAll() {
+ this.loading = true;
+ this.lastUploadError = null;
+ try {
+ const [s, list] = await Promise.all([
+ window.api.get("/api/v1/repository-server/status"),
+ window.api.get("/api/v1/repository-server/list"),
+ ]);
+ this.status = s.data;
+ this.entries = Array.isArray(list.data) ? list.data : [];
+ this.syncHttpFormFromStatus();
+ } catch (e) {
+ ToastUtils.error(this.$t("tools.repository_server.load_failed"));
+ console.error(e);
+ } finally {
+ this.loading = false;
+ }
+ },
+ buildHttpBody() {
+ const body = {};
+ const host = (this.httpHost || "").trim();
+ if (host) {
+ body.host = host;
+ }
+ const portRaw = (this.httpPort || "").trim();
+ if (portRaw !== "") {
+ const p = parseInt(portRaw, 10);
+ if (!Number.isFinite(p)) {
+ return { error: "invalid_port" };
+ }
+ body.port = p;
+ }
+ return { body };
+ },
+ async startHttp() {
+ const built = this.buildHttpBody();
+ if (built.error === "invalid_port") {
+ this.httpErrorToast("invalid_port");
+ return;
+ }
+ this.httpBusy = true;
+ try {
+ const { data } = await window.api.post("/api/v1/repository-server/http/start", built.body || {});
+ if (!data.ok) {
+ this.httpErrorToast(data.error, data.message);
+ } else {
+ ToastUtils.success(this.$t("tools.repository_server.http_started"));
+ }
+ await this.loadAll();
+ } catch (e) {
+ ToastUtils.error(this.$t("tools.repository_server.http_err_generic"));
+ console.error(e);
+ } finally {
+ this.httpBusy = false;
+ }
+ },
+ async stopHttp() {
+ this.httpBusy = true;
+ try {
+ await window.api.post("/api/v1/repository-server/http/stop");
+ ToastUtils.success(this.$t("tools.repository_server.http_stopped"));
+ await this.loadAll();
+ } catch (e) {
+ ToastUtils.error(this.$t("tools.repository_server.http_err_generic"));
+ console.error(e);
+ } finally {
+ this.httpBusy = false;
+ }
+ },
+ async restartHttp() {
+ const built = this.buildHttpBody();
+ if (built.error === "invalid_port") {
+ this.httpErrorToast("invalid_port");
+ return;
+ }
+ this.httpBusy = true;
+ try {
+ const { data } = await window.api.post("/api/v1/repository-server/http/restart", built.body || {});
+ if (!data.ok) {
+ this.httpErrorToast(data.error, data.message);
+ } else {
+ ToastUtils.success(this.$t("tools.repository_server.http_restarted"));
+ }
+ await this.loadAll();
+ } catch (e) {
+ ToastUtils.error(this.$t("tools.repository_server.http_err_generic"));
+ console.error(e);
+ } finally {
+ this.httpBusy = false;
+ }
+ },
+ async refreshBundled() {
+ this.refreshing = true;
+ ToastUtils.info(this.$t("tools.repository_server.refresh_started"));
+ this.stopRefreshPoll();
+ this.refreshPollId = setInterval(() => {
+ window.api
+ .get("/api/v1/repository-server/status")
+ .then(({ data }) => {
+ this.status = data;
+ this.syncHttpFormFromStatus();
+ })
+ .catch(() => {});
+ }, 350);
+ try {
+ const { data } = await window.api.post("/api/v1/repository-server/refresh-bundled");
+ if (data.error === "pip_not_found") {
+ ToastUtils.error(this.$t("tools.repository_server.refresh_pip_missing"));
+ await this.loadAll();
+ return;
+ }
+ const nFail = data.failed ? Object.keys(data.failed).length : 0;
+ const nOk = data.downloaded ? data.downloaded.length : 0;
+ if (nFail > 0 && nOk === 0) {
+ ToastUtils.error(this.$t("tools.repository_server.refresh_failed"));
+ } else if (nFail > 0) {
+ ToastUtils.warning(this.$t("tools.repository_server.refresh_partial_toast"));
+ ToastUtils.success(this.$t("tools.repository_server.refresh_ok_count", { n: nOk }));
+ } else {
+ ToastUtils.success(this.$t("tools.repository_server.refresh_ok"));
+ }
+ await this.loadAll();
+ } catch (e) {
+ ToastUtils.error(this.$t("tools.repository_server.refresh_failed"));
+ console.error(e);
+ await this.loadAll();
+ } finally {
+ this.stopRefreshPoll();
+ this.refreshing = false;
+ }
+ },
+ async onUpload(ev) {
+ const input = ev.target;
+ const file = input.files && input.files[0];
+ input.value = "";
+ if (!file) return;
+ this.lastUploadError = null;
+ const form = new FormData();
+ form.append("file", file, file.name);
+ try {
+ await window.api.post("/api/v1/repository-server/upload", form);
+ ToastUtils.success(this.$t("tools.repository_server.upload_ok"));
+ await this.loadAll();
+ } catch (e) {
+ this.lastUploadError =
+ e.response?.data?.error || e.message || this.$t("tools.repository_server.upload_failed");
+ ToastUtils.error(this.$t("tools.repository_server.upload_failed"));
+ }
+ },
+ async deleteUpload(name) {
+ if (!window.confirm(this.$t("tools.repository_server.delete_confirm", { name }))) {
+ return;
+ }
+ try {
+ const enc = encodeURIComponent(name);
+ await window.api.delete(`/api/v1/repository-server/upload/${enc}`);
+ ToastUtils.success(this.$t("tools.repository_server.delete_ok"));
+ await this.loadAll();
+ } catch (e) {
+ ToastUtils.error(this.$t("tools.repository_server.delete_failed"));
+ console.error(e);
+ }
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/tools/ToolsPage.vue b/meshchatx/src/frontend/components/tools/ToolsPage.vue
index 97d12359..b7ab6b0d 100644
--- a/meshchatx/src/frontend/components/tools/ToolsPage.vue
+++ b/meshchatx/src/frontend/components/tools/ToolsPage.vue
@@ -195,8 +195,8 @@ export default {
route: { name: "propagation-nodes" },
icon: "mailbox",
iconBg: "tool-card__icon bg-cyan-50 text-cyan-500 dark:bg-cyan-900/30 dark:text-cyan-200",
- title: "Propagation Nodes",
- description: "Manage preferred and local propagation nodes with live stats and path checks.",
+ titleKey: "tools.propagation_nodes.title",
+ descriptionKey: "tools.propagation_nodes.description",
},
{
name: "forwarder",
@@ -222,6 +222,14 @@ export default {
titleKey: "docs.title",
descriptionKey: "docs.subtitle",
},
+ {
+ name: "repository-server",
+ route: { name: "repository-server" },
+ icon: "package-variant",
+ iconBg: "tool-card__icon bg-sky-50 text-sky-600 dark:bg-sky-900/30 dark:text-sky-200",
+ titleKey: "tools.repository_server.title",
+ descriptionKey: "tools.repository_server.description",
+ },
{
name: "micron-editor",
route: { name: "micron-editor" },
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────